Put shared security headers in the template headers.json
What you'll see
You maintain an app (or a package app that sites use as their template) and want a Content-Security-Policy, or any other header, on every site built from it. The documentation says headers.json is legacy and that headers belong in the headers section of site.json, so you wonder which of the two files an app should use.
Or you set the policy in one site's #/headers.json to try it out, and then expect the other sites on the same template to have it too.
What's actually happening
#/headers.json and #/site.json are read from two places: the publication's own # folder and the app's root. See Do not create a # folder inside an app.
The site inherits the app's headers key by key. Docly merges four layers, each on top of the previous one: the headers section of the app's site.json, the app's headers.json, the headers section of the site's own site.json, and the site's own #/headers.json. A site that sets one header changes that header only; every other header, including the app's CSP, still comes from the app. A header set to null is removed, including a header Docly would otherwise send by default. "$inherit": false in the site's headers (in either of its two files) makes the site ignore the app's headers entirely. See Configure site settings and inherit them from the app.
Within one owner, headers.json beats site.json. If the headers block in a site.json sets the same header as the headers.json next to it, the headers.json value is used, and the site console shows a warning, since it is often a forgotten copy. site.json only fills in headers that headers.json does not set (Docly #29118). Between the site and the app, the site wins: a headers block in a site's site.json can change a header the app sets in its headers.json, including the CSP.
Docly does not copy the app's site.json into the site. The settings Docly writes itself, such as the image sizes it discovers during ordinary page requests, go to the site's own #/images.json, never to site.json and never to the app. A template's site.json and its headers.json are therefore both live defaults for every site.
The header cache is keyed on the site and compares against the newest of the site's and the template's modification times, so a change to the template's headers.json reaches every site without republishing.
What to do
Put headers that every site should have in headers.json in the template's root: ./headers.json, not ./#/headers.json. Here the "legacy" label does not apply. The headers section of the template's site.json works as well; headers.json keeps the policy in a file of its own.
{
"Content-Security-Policy": "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'"
}- Give each template its own policy. Two templates published from the same site folder (an admin app and a public app, say) are two templates. Each needs its own file, and only the public one should allow what an embedded widget or Turnstile needs.
- Do not leave a test copy in a site. A
#/headers.jsonyou added to one site to try a policy keeps overriding every header it names, the CSP included. Delete it once the template has the policy, or that site will never get later changes to those headers. - Override per site only what differs. A site that needs a different value sets just that header in its own
#/headers.json(or theheadersblock of itssite.json); the other headers keep following the template. Set a header tonullto remove it, and use"$inherit": falseonly when the site should own every header itself. - A template's
site.jsonis a live default too. Itsheaders,minify,cache,images,securityandhashjssections are inherited key by key, likeheaders.json. If the template sets the same header in both files,headers.jsonwins. - A JSON error in
headers.jsondrops that file. The file is skipped and the error is shown in the site console; the other layers still apply. The headers only that file sets are missing until it is fixed, and in a template that means the CSP is gone on every site at once, so validate the JSON before you save it.
Start a CSP as Content-Security-Policy-Report-Only and switch the key to Content-Security-Policy once no page reports anything. A strict script-src 'self' means no inline <script> blocks and no onclick= attributes. Move the code into files, and pass server values as data- attributes encoded with docly.htmlAttributeEncode() (see Hash expressions do not escape HTML). The browser reports violations only in its developer tools. A securitypolicyviolation listener loaded first in <head> lets you collect them yourself.